diff --git a/EnvelopeGenerator.Jobs/EnvelopeGenerator.Jobs.csproj b/EnvelopeGenerator.Jobs/EnvelopeGenerator.Jobs.csproj
index fa71b7ae..1456f1ac 100644
--- a/EnvelopeGenerator.Jobs/EnvelopeGenerator.Jobs.csproj
+++ b/EnvelopeGenerator.Jobs/EnvelopeGenerator.Jobs.csproj
@@ -6,4 +6,36 @@
enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/EnvelopeGenerator.Jobs/Jobs/APIBackendJobs/APIEnvelopeJob.cs b/EnvelopeGenerator.Jobs/Jobs/APIBackendJobs/APIEnvelopeJob.cs
new file mode 100644
index 00000000..7b7df8f3
--- /dev/null
+++ b/EnvelopeGenerator.Jobs/Jobs/APIBackendJobs/APIEnvelopeJob.cs
@@ -0,0 +1,160 @@
+using System.Collections.Generic;
+using System.Data;
+using System.Threading.Tasks;
+using EnvelopeGenerator.Domain.Constants;
+using Microsoft.Data.SqlClient;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Quartz;
+
+namespace EnvelopeGenerator.CommonServices.Jobs.APIBackendJobs;
+
+public class APIEnvelopeJob : IJob
+{
+ private readonly ILogger _logger;
+
+ public APIEnvelopeJob() : this(NullLogger.Instance)
+ {
+ }
+
+ public APIEnvelopeJob(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public async Task Execute(IJobExecutionContext context)
+ {
+ var jobId = context.JobDetail.Key.ToString();
+ _logger.LogDebug("API Envelopes - Starting job {JobId}", jobId);
+
+ try
+ {
+ var connectionString = context.MergedJobDataMap.GetString(Value.DATABASE);
+ if (string.IsNullOrWhiteSpace(connectionString))
+ {
+ _logger.LogWarning("API Envelopes - Connection string missing");
+ return;
+ }
+
+ await using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync(context.CancellationToken);
+
+ await ProcessInvitationsAsync(connection, context.CancellationToken);
+ await ProcessWithdrawnAsync(connection, context.CancellationToken);
+
+ _logger.LogDebug("API Envelopes - Completed job {JobId} successfully", jobId);
+ }
+ catch (System.Exception ex)
+ {
+ _logger.LogError(ex, "API Envelopes job failed");
+ }
+ finally
+ {
+ _logger.LogDebug("API Envelopes execution for {JobId} ended", jobId);
+ }
+ }
+
+ private async Task ProcessInvitationsAsync(SqlConnection connection, System.Threading.CancellationToken cancellationToken)
+ {
+ const string sql = "SELECT GUID FROM TBSIG_ENVELOPE WHERE SOURCE = 'API' AND STATUS = 1003 ORDER BY GUID";
+ var envelopeIds = new List();
+
+ await using (var command = new SqlCommand(sql, connection))
+ await using (var reader = await command.ExecuteReaderAsync(cancellationToken))
+ {
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ if (reader[0] is int id)
+ {
+ envelopeIds.Add(id);
+ }
+ }
+ }
+
+ if (envelopeIds.Count == 0)
+ {
+ _logger.LogDebug("SendInvMail - No envelopes found");
+ return;
+ }
+
+ _logger.LogInformation("SendInvMail - Found {Count} envelopes", envelopeIds.Count);
+ var total = envelopeIds.Count;
+ var current = 1;
+
+ foreach (var id in envelopeIds)
+ {
+ _logger.LogInformation("SendInvMail - Processing Envelope {EnvelopeId} ({Current}/{Total})", id, current, total);
+ try
+ {
+ // Placeholder for invitation email sending logic.
+ _logger.LogDebug("SendInvMail - Marking envelope {EnvelopeId} as queued", id);
+ const string updateSql = "UPDATE TBSIG_ENVELOPE SET CURRENT_WORK_APP = @App WHERE GUID = @Id";
+ await using var updateCommand = new SqlCommand(updateSql, connection);
+ updateCommand.Parameters.AddWithValue("@App", "signFLOW_API_EnvJob_InvMail");
+ updateCommand.Parameters.AddWithValue("@Id", id);
+ await updateCommand.ExecuteNonQueryAsync(cancellationToken);
+ }
+ catch (System.Exception ex)
+ {
+ _logger.LogWarning(ex, "SendInvMail - Unhandled exception while working envelope {EnvelopeId}", id);
+ }
+
+ current++;
+ _logger.LogInformation("SendInvMail - Envelope finalized");
+ }
+ }
+
+ private async Task ProcessWithdrawnAsync(SqlConnection connection, System.Threading.CancellationToken cancellationToken)
+ {
+ const string sql = @"SELECT ENV.GUID, REJ.COMMENT AS REJECTION_REASON FROM
+ (SELECT * FROM TBSIG_ENVELOPE WHERE STATUS = 1009 AND SOURCE = 'API') ENV INNER JOIN
+ (SELECT MAX(GUID) GUID, ENVELOPE_ID, MAX(ADDED_WHEN) ADDED_WHEN, MAX(ACTION_DATE) ACTION_DATE, COMMENT FROM TBSIG_ENVELOPE_HISTORY WHERE STATUS = 1009 GROUP BY ENVELOPE_ID, COMMENT ) REJ ON ENV.GUID = REJ.ENVELOPE_ID LEFT JOIN
+ (SELECT * FROM TBSIG_ENVELOPE_HISTORY WHERE STATUS = 3004 ) M_Send ON ENV.GUID = M_Send.ENVELOPE_ID
+ WHERE M_Send.GUID IS NULL";
+
+ var withdrawn = new List<(int EnvelopeId, string Reason)>();
+ await using (var command = new SqlCommand(sql, connection))
+ await using (var reader = await command.ExecuteReaderAsync(cancellationToken))
+ {
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ var id = reader.GetInt32(0);
+ var reason = reader.IsDBNull(1) ? string.Empty : reader.GetString(1);
+ withdrawn.Add((id, reason));
+ }
+ }
+
+ if (withdrawn.Count == 0)
+ {
+ _logger.LogDebug("WithdrawnEnv - No envelopes found");
+ return;
+ }
+
+ _logger.LogInformation("WithdrawnEnv - Found {Count} envelopes", withdrawn.Count);
+ var total = withdrawn.Count;
+ var current = 1;
+
+ foreach (var (envelopeId, reason) in withdrawn)
+ {
+ _logger.LogInformation("WithdrawnEnv - Processing Envelope {EnvelopeId} ({Current}/{Total})", envelopeId, current, total);
+ try
+ {
+ // Log withdrawn mail trigger placeholder
+ const string insertHistory = "INSERT INTO TBSIG_ENVELOPE_HISTORY (ENVELOPE_ID, STATUS, USER_REFERENCE, ADDED_WHEN, ACTION_DATE, COMMENT) VALUES (@EnvelopeId, @Status, @UserReference, GETDATE(), GETDATE(), @Comment)";
+ await using var insertCommand = new SqlCommand(insertHistory, connection);
+ insertCommand.Parameters.AddWithValue("@EnvelopeId", envelopeId);
+ insertCommand.Parameters.AddWithValue("@Status", 3004);
+ insertCommand.Parameters.AddWithValue("@UserReference", "API");
+ insertCommand.Parameters.AddWithValue("@Comment", reason ?? string.Empty);
+ await insertCommand.ExecuteNonQueryAsync(cancellationToken);
+ }
+ catch (System.Exception ex)
+ {
+ _logger.LogWarning(ex, "WithdrawnEnv - Unhandled exception while working envelope {EnvelopeId}", envelopeId);
+ }
+
+ current++;
+ _logger.LogInformation("WithdrawnEnv - Envelope finalized");
+ }
+ }
+}
diff --git a/EnvelopeGenerator.Jobs/Jobs/DataRowExtensions.cs b/EnvelopeGenerator.Jobs/Jobs/DataRowExtensions.cs
new file mode 100644
index 00000000..2ec940e5
--- /dev/null
+++ b/EnvelopeGenerator.Jobs/Jobs/DataRowExtensions.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Data;
+
+namespace EnvelopeGenerator.CommonServices.Jobs;
+
+public static class DataRowExtensions
+{
+ public static T? GetValueOrDefault(this DataRow row, string columnName, T? defaultValue = default)
+ {
+ if (!row.Table.Columns.Contains(columnName))
+ {
+ return defaultValue;
+ }
+
+ var value = row[columnName];
+ if (value == DBNull.Value)
+ {
+ return defaultValue;
+ }
+
+ try
+ {
+ return (T)Convert.ChangeType(value, typeof(T));
+ }
+ catch
+ {
+ return defaultValue;
+ }
+ }
+}
diff --git a/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/FinalizeDocumentExceptions.cs b/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/FinalizeDocumentExceptions.cs
new file mode 100644
index 00000000..cd24ec74
--- /dev/null
+++ b/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/FinalizeDocumentExceptions.cs
@@ -0,0 +1,28 @@
+namespace EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument;
+
+public static class FinalizeDocumentExceptions
+{
+ public class MergeDocumentException : ApplicationException
+ {
+ public MergeDocumentException(string message) : base(message) { }
+ public MergeDocumentException(string message, Exception innerException) : base(message, innerException) { }
+ }
+
+ public class BurnAnnotationException : ApplicationException
+ {
+ public BurnAnnotationException(string message) : base(message) { }
+ public BurnAnnotationException(string message, Exception innerException) : base(message, innerException) { }
+ }
+
+ public class CreateReportException : ApplicationException
+ {
+ public CreateReportException(string message) : base(message) { }
+ public CreateReportException(string message, Exception innerException) : base(message, innerException) { }
+ }
+
+ public class ExportDocumentException : ApplicationException
+ {
+ public ExportDocumentException(string message) : base(message) { }
+ public ExportDocumentException(string message, Exception innerException) : base(message, innerException) { }
+ }
+}
diff --git a/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/FinalizeDocumentJob.cs b/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/FinalizeDocumentJob.cs
new file mode 100644
index 00000000..4be4e3c9
--- /dev/null
+++ b/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/FinalizeDocumentJob.cs
@@ -0,0 +1,229 @@
+using System.Collections.Generic;
+using System.Data;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument.FinalizeDocumentExceptions;
+using EnvelopeGenerator.Domain.Constants;
+using EnvelopeGenerator.Domain.Entities;
+using Microsoft.Data.SqlClient;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Quartz;
+
+namespace EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument;
+
+public class FinalizeDocumentJob : IJob
+{
+ private readonly ILogger _logger;
+ private readonly PDFBurner _pdfBurner;
+ private readonly PDFMerger _pdfMerger;
+ private readonly ReportCreator _reportCreator;
+
+ private record ConfigSettings(string DocumentPath, string DocumentPathOrigin, string ExportPath);
+
+ public FinalizeDocumentJob()
+ : this(NullLogger.Instance)
+ {
+ }
+
+ public FinalizeDocumentJob(ILogger logger)
+ {
+ _logger = logger;
+ _pdfBurner = new PDFBurner();
+ _pdfMerger = new PDFMerger();
+ _reportCreator = new ReportCreator();
+ }
+
+ public async Task Execute(IJobExecutionContext context)
+ {
+ var jobId = context.JobDetail.Key.ToString();
+ _logger.LogDebug("Starting job {JobId}", jobId);
+
+ try
+ {
+ var connectionString = context.MergedJobDataMap.GetString(Value.DATABASE);
+ if (string.IsNullOrWhiteSpace(connectionString))
+ {
+ _logger.LogWarning("FinalizeDocument - Connection string missing");
+ return;
+ }
+
+ await using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync(context.CancellationToken);
+
+ var config = await LoadConfigurationAsync(connection, context.CancellationToken);
+ var envelopes = await LoadCompletedEnvelopesAsync(connection, context.CancellationToken);
+
+ if (envelopes.Count == 0)
+ {
+ _logger.LogInformation("No completed envelopes found");
+ return;
+ }
+
+ var total = envelopes.Count;
+ var current = 1;
+
+ foreach (var envelopeId in envelopes)
+ {
+ _logger.LogInformation("Finalizing Envelope {EnvelopeId} ({Current}/{Total})", envelopeId, current, total);
+ try
+ {
+ var envelopeData = await GetEnvelopeDataAsync(connection, envelopeId, context.CancellationToken);
+ if (envelopeData is null)
+ {
+ _logger.LogWarning("Envelope data not found for {EnvelopeId}", envelopeId);
+ continue;
+ }
+
+ var envelope = new Envelope
+ {
+ Id = envelopeId,
+ Uuid = envelopeData.EnvelopeUuid ?? string.Empty,
+ Title = envelopeData.Title ?? string.Empty,
+ FinalEmailToCreator = FinalEmailType.No,
+ FinalEmailToReceivers = FinalEmailType.No
+ };
+
+ var burned = _pdfBurner.BurnAnnotsToPDF(envelopeData.DocumentBytes, envelopeData.AnnotationData, envelopeId);
+ var report = _reportCreator.CreateReport(connection, envelope);
+ var merged = _pdfMerger.MergeDocuments(burned, report);
+
+ var outputDirectory = Path.Combine(config.ExportPath, envelopeData.ParentFolderUid);
+ Directory.CreateDirectory(outputDirectory);
+ var outputPath = Path.Combine(outputDirectory, $"{envelope.Uuid}.pdf");
+ await File.WriteAllBytesAsync(outputPath, merged, context.CancellationToken);
+
+ await UpdateDocumentResultAsync(connection, envelopeId, merged, context.CancellationToken);
+ await ArchiveEnvelopeAsync(connection, envelopeId, context.CancellationToken);
+ }
+ catch (MergeDocumentException ex)
+ {
+ _logger.LogWarning(ex, "Certificate Document job failed at merging documents");
+ }
+ catch (ExportDocumentException ex)
+ {
+ _logger.LogWarning(ex, "Certificate Document job failed at exporting document");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Unhandled exception while working envelope {EnvelopeId}", envelopeId);
+ }
+
+ current++;
+ _logger.LogInformation("Envelope {EnvelopeId} finalized", envelopeId);
+ }
+
+ _logger.LogDebug("Completed job {JobId} successfully", jobId);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Certificate Document job failed");
+ }
+ finally
+ {
+ _logger.LogDebug("Job execution for {JobId} ended", jobId);
+ }
+ }
+
+ private async Task LoadConfigurationAsync(SqlConnection connection, CancellationToken cancellationToken)
+ {
+ const string sql = "SELECT TOP 1 DOCUMENT_PATH, EXPORT_PATH FROM TBSIG_CONFIG";
+ await using var command = new SqlCommand(sql, connection);
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ if (await reader.ReadAsync(cancellationToken))
+ {
+ var documentPath = reader.IsDBNull(0) ? string.Empty : reader.GetString(0);
+ var exportPath = reader.IsDBNull(1) ? string.Empty : reader.GetString(1);
+ return new ConfigSettings(documentPath, documentPath, exportPath);
+ }
+
+ return new ConfigSettings(string.Empty, string.Empty, Path.GetTempPath());
+ }
+
+ private async Task> LoadCompletedEnvelopesAsync(SqlConnection connection, CancellationToken cancellationToken)
+ {
+ const string sql = "SELECT GUID FROM TBSIG_ENVELOPE WHERE STATUS = @Status AND DATEDIFF(minute, CHANGED_WHEN, GETDATE()) >= 1 ORDER BY GUID";
+ var ids = new List();
+ await using var command = new SqlCommand(sql, connection);
+ command.Parameters.AddWithValue("@Status", (int)EnvelopeStatus.EnvelopeCompletelySigned);
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ ids.Add(reader.GetInt32(0));
+ }
+
+ return ids;
+ }
+
+ private async Task<(int EnvelopeId, string? EnvelopeUuid, string? Title, byte[] DocumentBytes, List AnnotationData, string ParentFolderUid)?> GetEnvelopeDataAsync(SqlConnection connection, int envelopeId, CancellationToken cancellationToken)
+ {
+ const string sql = @"SELECT T.GUID, T.ENVELOPE_UUID, T.TITLE, T2.FILEPATH, T2.BYTE_DATA FROM [dbo].[TBSIG_ENVELOPE] T
+ JOIN TBSIG_ENVELOPE_DOCUMENT T2 ON T.GUID = T2.ENVELOPE_ID
+ WHERE T.GUID = @EnvelopeId";
+
+ await using var command = new SqlCommand(sql, connection);
+ command.Parameters.AddWithValue("@EnvelopeId", envelopeId);
+ await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SingleRow, cancellationToken);
+ if (!await reader.ReadAsync(cancellationToken))
+ {
+ return null;
+ }
+
+ var envelopeUuid = reader.IsDBNull(1) ? string.Empty : reader.GetString(1);
+ var title = reader.IsDBNull(2) ? string.Empty : reader.GetString(2);
+ var filePath = reader.IsDBNull(3) ? string.Empty : reader.GetString(3);
+ var bytes = reader.IsDBNull(4) ? Array.Empty() : (byte[])reader[4];
+ await reader.CloseAsync();
+
+ if (bytes.Length == 0 && !string.IsNullOrWhiteSpace(filePath) && File.Exists(filePath))
+ {
+ bytes = await File.ReadAllBytesAsync(filePath, cancellationToken);
+ }
+
+ var annotations = await GetAnnotationDataAsync(connection, envelopeId, cancellationToken);
+
+ var parentFolderUid = !string.IsNullOrWhiteSpace(filePath)
+ ? Path.GetFileName(Path.GetDirectoryName(filePath) ?? string.Empty)
+ : envelopeUuid;
+
+ return (envelopeId, envelopeUuid, title, bytes, annotations, parentFolderUid ?? envelopeUuid ?? envelopeId.ToString());
+ }
+
+ private async Task> GetAnnotationDataAsync(SqlConnection connection, int envelopeId, CancellationToken cancellationToken)
+ {
+ const string sql = "SELECT VALUE FROM TBSIG_DOCUMENT_STATUS WHERE ENVELOPE_ID = @EnvelopeId";
+ var result = new List();
+ await using var command = new SqlCommand(sql, connection);
+ command.Parameters.AddWithValue("@EnvelopeId", envelopeId);
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ if (!reader.IsDBNull(0))
+ {
+ result.Add(reader.GetString(0));
+ }
+ }
+
+ return result;
+ }
+
+ private static async Task UpdateDocumentResultAsync(SqlConnection connection, int envelopeId, byte[] bytes, CancellationToken cancellationToken)
+ {
+ const string sql = "UPDATE TBSIG_ENVELOPE SET DOC_RESULT = @ImageData WHERE GUID = @EnvelopeId";
+ await using var command = new SqlCommand(sql, connection);
+ command.Parameters.AddWithValue("@ImageData", bytes);
+ command.Parameters.AddWithValue("@EnvelopeId", envelopeId);
+ await command.ExecuteNonQueryAsync(cancellationToken);
+ }
+
+ private static async Task ArchiveEnvelopeAsync(SqlConnection connection, int envelopeId, CancellationToken cancellationToken)
+ {
+ const string sql = "UPDATE TBSIG_ENVELOPE SET STATUS = @Status, CHANGED_WHEN = GETDATE() WHERE GUID = @EnvelopeId";
+ await using var command = new SqlCommand(sql, connection);
+ command.Parameters.AddWithValue("@Status", (int)EnvelopeStatus.EnvelopeArchived);
+ command.Parameters.AddWithValue("@EnvelopeId", envelopeId);
+ await command.ExecuteNonQueryAsync(cancellationToken);
+ }
+}
diff --git a/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/PDFBurner.cs b/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/PDFBurner.cs
new file mode 100644
index 00000000..c140e247
--- /dev/null
+++ b/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/PDFBurner.cs
@@ -0,0 +1,248 @@
+using System.Collections.Generic;
+using System.Drawing;
+using System.Linq;
+using EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument.FinalizeDocumentExceptions;
+using iText.IO.Image;
+using iText.Kernel.Colors;
+using iText.Kernel.Pdf;
+using iText.Kernel.Pdf.Canvas;
+using iText.Layout;
+using iText.Layout.Element;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Newtonsoft.Json;
+
+namespace EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument;
+
+public class PDFBurner
+{
+ private readonly ILogger _logger;
+ private readonly PDFBurnerParams _pdfBurnerParams;
+
+ public PDFBurner() : this(NullLogger.Instance, new PDFBurnerParams())
+ {
+ }
+
+ public PDFBurner(ILogger logger, PDFBurnerParams? pdfBurnerParams = null)
+ {
+ _logger = logger;
+ _pdfBurnerParams = pdfBurnerParams ?? new PDFBurnerParams();
+ }
+
+ public byte[] BurnAnnotsToPDF(byte[] sourceBuffer, IList instantJsonList, int envelopeId)
+ {
+ if (sourceBuffer is null || sourceBuffer.Length == 0)
+ {
+ throw new BurnAnnotationException("Source document is empty");
+ }
+
+ try
+ {
+ using var inputStream = new MemoryStream(sourceBuffer);
+ using var outputStream = new MemoryStream();
+ using var reader = new PdfReader(inputStream);
+ using var writer = new PdfWriter(outputStream);
+ using var pdf = new PdfDocument(reader, writer);
+
+ foreach (var json in instantJsonList ?? Enumerable.Empty())
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ continue;
+ }
+
+ var annotationData = JsonConvert.DeserializeObject(json);
+ if (annotationData?.annotations is null)
+ {
+ continue;
+ }
+
+ annotationData.annotations.Reverse();
+
+ foreach (var annotation in annotationData.annotations)
+ {
+ try
+ {
+ switch (annotation.type)
+ {
+ case AnnotationType.Image:
+ AddImageAnnotation(pdf, annotation, annotationData.attachments);
+ break;
+ case AnnotationType.Ink:
+ AddInkAnnotation(pdf, annotation);
+ break;
+ case AnnotationType.Widget:
+ var formFieldValue = annotationData.formFieldValues?.FirstOrDefault(fv => fv.name == annotation.id);
+ if (formFieldValue is not null && !_pdfBurnerParams.IgnoredLabels.Contains(formFieldValue.value))
+ {
+ AddFormFieldValue(pdf, annotation, formFieldValue.value);
+ }
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Error applying annotation {AnnotationId} on envelope {EnvelopeId}", annotation.id, envelopeId);
+ throw new BurnAnnotationException("Adding annotation failed", ex);
+ }
+ }
+ }
+
+ pdf.Close();
+ return outputStream.ToArray();
+ }
+ catch (BurnAnnotationException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to burn annotations for envelope {EnvelopeId}", envelopeId);
+ throw new BurnAnnotationException("Annotations could not be burned", ex);
+ }
+ }
+
+ private void AddImageAnnotation(PdfDocument pdf, Annotation annotation, Dictionary? attachments)
+ {
+ if (attachments is null || string.IsNullOrWhiteSpace(annotation.imageAttachmentId) || !attachments.TryGetValue(annotation.imageAttachmentId, out var attachment))
+ {
+ return;
+ }
+
+ var page = pdf.GetPage(annotation.pageIndex + 1);
+ var canvas = new PdfCanvas(page);
+ var bounds = annotation.bbox.Select(ToInches).ToList();
+ var x = bounds[0];
+ var y = bounds[1];
+ var width = bounds[2];
+ var height = bounds[3];
+
+ var imageBytes = Convert.FromBase64String(attachment.binary);
+ var imageData = ImageDataFactory.Create(imageBytes);
+ canvas.AddImageAt(imageData, x, y, false)
+ .ScaleToFit(width, height);
+ }
+
+ private void AddInkAnnotation(PdfDocument pdf, Annotation annotation)
+ {
+ if (annotation.lines?.points is null)
+ {
+ return;
+ }
+
+ var page = pdf.GetPage(annotation.pageIndex + 1);
+ var canvas = new PdfCanvas(page);
+ var color = ParseColor(annotation.strokeColor);
+ canvas.SetStrokeColor(color);
+ canvas.SetLineWidth(1);
+
+ foreach (var segment in annotation.lines.points)
+ {
+ var first = true;
+ foreach (var point in segment)
+ {
+ var (px, py) = (ToInches(point[0]), ToInches(point[1]));
+ if (first)
+ {
+ canvas.MoveTo(px, py);
+ first = false;
+ }
+ else
+ {
+ canvas.LineTo(px, py);
+ }
+ }
+ canvas.Stroke();
+ }
+ }
+
+ private void AddFormFieldValue(PdfDocument pdf, Annotation annotation, string value)
+ {
+ var bounds = annotation.bbox.Select(ToInches).ToList();
+ var x = bounds[0];
+ var y = bounds[1];
+ var width = bounds[2];
+ var height = bounds[3];
+
+ var page = pdf.GetPage(annotation.pageIndex + 1);
+ var canvas = new Canvas(new PdfCanvas(page), page.GetPageSize());
+ canvas.ShowTextAligned(new Paragraph(value)
+ .SetFontSize(_pdfBurnerParams.FontSize)
+ .SetFontColor(ColorConstants.BLACK)
+ .SetFontFamily(_pdfBurnerParams.FontName)
+ .SetItalic(_pdfBurnerParams.FontStyle.HasFlag(FontStyle.Italic))
+ .SetBold(_pdfBurnerParams.FontStyle.HasFlag(FontStyle.Bold)),
+ x + _pdfBurnerParams.TopMargin,
+ y + _pdfBurnerParams.YOffset,
+ annotation.pageIndex + 1,
+ iText.Layout.Properties.TextAlignment.LEFT,
+ iText.Layout.Properties.VerticalAlignment.TOP,
+ 0);
+ }
+
+ private static DeviceRgb ParseColor(string? color)
+ {
+ if (string.IsNullOrWhiteSpace(color))
+ {
+ return new DeviceRgb(0, 0, 0);
+ }
+
+ try
+ {
+ var drawingColor = ColorTranslator.FromHtml(color);
+ return new DeviceRgb(drawingColor.R, drawingColor.G, drawingColor.B);
+ }
+ catch
+ {
+ return new DeviceRgb(0, 0, 0);
+ }
+ }
+
+ private static double ToInches(double value) => value / 72d;
+ private static double ToInches(float value) => value / 72d;
+
+ #region Model
+ private static class AnnotationType
+ {
+ public const string Image = "pspdfkit/image";
+ public const string Ink = "pspdfkit/ink";
+ public const string Widget = "pspdfkit/widget";
+ }
+
+ private sealed class AnnotationData
+ {
+ public List? annotations { get; set; }
+ public Dictionary? attachments { get; set; }
+ public List? formFieldValues { get; set; }
+ }
+
+ private sealed class Annotation
+ {
+ public string id { get; set; } = string.Empty;
+ public List bbox { get; set; } = new();
+ public string type { get; set; } = string.Empty;
+ public string imageAttachmentId { get; set; } = string.Empty;
+ public Lines? lines { get; set; }
+ public int pageIndex { get; set; }
+ public string strokeColor { get; set; } = string.Empty;
+ public string egName { get; set; } = string.Empty;
+ }
+
+ private sealed class Lines
+ {
+ public List>> points { get; set; } = new();
+ }
+
+ private sealed class Attachment
+ {
+ public string binary { get; set; } = string.Empty;
+ public string contentType { get; set; } = string.Empty;
+ }
+
+ private sealed class FormFieldValue
+ {
+ public string name { get; set; } = string.Empty;
+ public string value { get; set; } = string.Empty;
+ }
+ #endregion
+}
diff --git a/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/PDFBurnerParams.cs b/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/PDFBurnerParams.cs
new file mode 100644
index 00000000..f708ca11
--- /dev/null
+++ b/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/PDFBurnerParams.cs
@@ -0,0 +1,19 @@
+using System.Collections.Generic;
+using System.Drawing;
+
+namespace EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument;
+
+public class PDFBurnerParams
+{
+ public List IgnoredLabels { get; } = new() { "Date", "Datum", "ZIP", "PLZ", "Place", "Ort", "Position", "Stellung" };
+
+ public double TopMargin { get; set; } = 0.1;
+
+ public double YOffset { get; set; } = -0.3;
+
+ public string FontName { get; set; } = "Arial";
+
+ public int FontSize { get; set; } = 8;
+
+ public FontStyle FontStyle { get; set; } = FontStyle.Italic;
+}
diff --git a/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/PDFMerger.cs b/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/PDFMerger.cs
new file mode 100644
index 00000000..134c20be
--- /dev/null
+++ b/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/PDFMerger.cs
@@ -0,0 +1,46 @@
+using System.IO;
+using EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument.FinalizeDocumentExceptions;
+using iText.Kernel.Pdf;
+using iText.Kernel.Utils;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument;
+
+public class PDFMerger
+{
+ private readonly ILogger _logger;
+
+ public PDFMerger() : this(NullLogger.Instance)
+ {
+ }
+
+ public PDFMerger(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public byte[] MergeDocuments(byte[] document, byte[] report)
+ {
+ try
+ {
+ using var finalStream = new MemoryStream();
+ using var documentReader = new PdfReader(new MemoryStream(document));
+ using var reportReader = new PdfReader(new MemoryStream(report));
+ using var writer = new PdfWriter(finalStream);
+ using var targetDoc = new PdfDocument(documentReader, writer);
+ using var reportDoc = new PdfDocument(reportReader);
+
+ var merger = new PdfMerger(targetDoc);
+ merger.Merge(reportDoc, 1, reportDoc.GetNumberOfPages());
+
+ targetDoc.Close();
+ return finalStream.ToArray();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to merge PDF documents");
+ throw new MergeDocumentException("Documents could not be merged", ex);
+ }
+ }
+}
diff --git a/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/ReportCreator.cs b/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/ReportCreator.cs
new file mode 100644
index 00000000..5714deb2
--- /dev/null
+++ b/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/ReportCreator.cs
@@ -0,0 +1,93 @@
+using System.Data;
+using System.IO;
+using EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument.FinalizeDocumentExceptions;
+using EnvelopeGenerator.Domain.Entities;
+using iText.Kernel.Pdf;
+using iText.Layout;
+using iText.Layout.Element;
+using Microsoft.Data.SqlClient;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument;
+
+public class ReportCreator
+{
+ private readonly ILogger _logger;
+
+ public ReportCreator() : this(NullLogger.Instance)
+ {
+ }
+
+ public ReportCreator(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public byte[] CreateReport(SqlConnection connection, Envelope envelope)
+ {
+ try
+ {
+ var reportItems = LoadReportItems(connection, envelope.Id);
+ using var stream = new MemoryStream();
+ using var writer = new PdfWriter(stream);
+ using var pdf = new PdfDocument(writer);
+ using var document = new Document(pdf);
+
+ document.Add(new Paragraph("Envelope Finalization Report").SetFontSize(16));
+ document.Add(new Paragraph($"Envelope Id: {envelope.Id}"));
+ document.Add(new Paragraph($"UUID: {envelope.Uuid}"));
+ document.Add(new Paragraph($"Title: {envelope.Title}"));
+ document.Add(new Paragraph($"Subject: {envelope.Comment}"));
+ document.Add(new Paragraph($"Generated: {DateTime.UtcNow:O}"));
+ document.Add(new Paragraph(" "));
+
+ var table = new Table(4).UseAllAvailableWidth();
+ table.AddHeaderCell("Date");
+ table.AddHeaderCell("Status");
+ table.AddHeaderCell("User");
+ table.AddHeaderCell("EnvelopeId");
+
+ foreach (var item in reportItems.OrderByDescending(r => r.ItemDate))
+ {
+ table.AddCell(item.ItemDate.ToString("u"));
+ table.AddCell(item.ItemStatus.ToString());
+ table.AddCell(item.ItemUserReference);
+ table.AddCell(item.EnvelopeId.ToString());
+ }
+
+ document.Add(table);
+ document.Close();
+ return stream.ToArray();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Could not create report for envelope {EnvelopeId}", envelope.Id);
+ throw new CreateReportException("Could not prepare report data", ex);
+ }
+ }
+
+ private List LoadReportItems(SqlConnection connection, int envelopeId)
+ {
+ const string sql = "SELECT ENVELOPE_ID, HEAD_TITLE, HEAD_SUBJECT, POS_WHEN, POS_STATUS, POS_WHO FROM VWSIG_ENVELOPE_REPORT WHERE ENVELOPE_ID = @EnvelopeId";
+ var result = new List();
+
+ using var command = new SqlCommand(sql, connection);
+ command.Parameters.AddWithValue("@EnvelopeId", envelopeId);
+ using var reader = command.ExecuteReader();
+ while (reader.Read())
+ {
+ result.Add(new ReportItem
+ {
+ EnvelopeId = reader.GetInt32(0),
+ EnvelopeTitle = reader.IsDBNull(1) ? string.Empty : reader.GetString(1),
+ EnvelopeSubject = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
+ ItemDate = reader.IsDBNull(3) ? DateTime.MinValue : reader.GetDateTime(3),
+ ItemStatus = reader.IsDBNull(4) ? default : (EnvelopeGenerator.Domain.Constants.EnvelopeStatus)reader.GetInt32(4),
+ ItemUserReference = reader.IsDBNull(5) ? string.Empty : reader.GetString(5)
+ });
+ }
+
+ return result;
+ }
+}
diff --git a/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/ReportItem.cs b/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/ReportItem.cs
new file mode 100644
index 00000000..d81645c7
--- /dev/null
+++ b/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/ReportItem.cs
@@ -0,0 +1,19 @@
+using EnvelopeGenerator.Domain.Constants;
+using EnvelopeGenerator.Domain.Entities;
+
+namespace EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument;
+
+public class ReportItem
+{
+ public Envelope? Envelope { get; set; }
+ public int EnvelopeId { get; set; }
+ public string EnvelopeTitle { get; set; } = string.Empty;
+ public string EnvelopeSubject { get; set; } = string.Empty;
+
+ public EnvelopeStatus ItemStatus { get; set; }
+
+ public string ItemStatusTranslated => ItemStatus.ToString();
+
+ public string ItemUserReference { get; set; } = string.Empty;
+ public DateTime ItemDate { get; set; }
+}
diff --git a/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/ReportSource.cs b/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/ReportSource.cs
new file mode 100644
index 00000000..5cc3f12d
--- /dev/null
+++ b/EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/ReportSource.cs
@@ -0,0 +1,8 @@
+using System.Collections.Generic;
+
+namespace EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument;
+
+public class ReportSource
+{
+ public List Items { get; set; } = new();
+}