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(); +}